Managing UI State Using Observable, @Published, and @State
Course: iOS App Development
Topic: Managing UI State Using Observable, @Published, and @State
Course Link: iOS App Development Training at JustAcademy
Demo Link: Register for a Course Demo
Website: JustAcademy Official Website
1. Introduction to UI State Management
UI state management is the process of storing and updating the information that determines what users see and how they interact with an application. In iOS development, state can include text entered into a form, whether a button is enabled, a selected tab, a list of products, a loading indicator, or an error message.
SwiftUI uses a declarative approach to building interfaces. Instead of manually changing individual interface elements whenever data changes, developers describe how the interface should look for a particular state. SwiftUI observes relevant state changes and updates the interface when needed.
Three important tools used in SwiftUI state management are:
@State for managing local, view-owned state.
@Observable for making an ordinary model object observable using the Observation framework.
@Published for publishing property changes from an ObservableObject that uses the Combine-based observation system.
Understanding the differences between these tools helps developers choose the right approach for simple views, shared application data, forms, API responses, and larger applications.
2. Learning Objectives
- Understand what UI state is and why it matters.
- Learn how SwiftUI responds to state changes.
- Understand the purpose of
@State.
- Understand
ObservableObject and @Published.
- Learn how the modern
@Observable macro works.
- Compare the Observation framework with Combine-based observation.
- Pass state and bindings between SwiftUI views.
- Manage forms, lists, loading indicators, and error messages.
- Organize state in view models and shared data objects.
- Identify common state-management mistakes and best practices.
3. What Is UI State?
UI state is the information that determines the current appearance and behavior of an interface. When that information changes, the interface may need to display a different value, enable or disable a control, show a loading indicator, or navigate to another screen.
3.1 Examples of UI state
| UI element |
Example state |
Possible interface response |
| Text field |
Entered username |
Displays the latest text. |
| Toggle |
Enabled or disabled |
Reflects the current setting. |
| Button |
Form validity |
Becomes enabled when the form is valid. |
| Product list |
Array of products |
Displays updated rows when data changes. |
| Progress indicator |
Loading status |
Appears while a request is running. |
| Error message |
Optional error text |
Displays a message when an operation fails. |
| Navigation |
Selected destination |
Shows the relevant screen. |
3.2 Why state management is important
- Keeps displayed information synchronized with application data.
- Reduces manual interface updates.
- Improves code readability and testability.
- Supports reusable views and shared data.
- Makes loading, success, empty, and error states easier to represent.
- Helps prevent inconsistent information across different screens.
4. Understanding SwiftUI's Declarative Approach
In an imperative interface, developers often instruct individual UI elements to change whenever a value changes. SwiftUI instead allows developers to describe the interface as a function of the current state.
import SwiftUI
struct CounterView: View {
@State private var count = 0
var body: some View {
VStack(spacing: 16) {
Text("Count: \(count)")
.font(.title)
Button("Increase") {
count += 1
}
}
.padding()
}
}
When the user taps the button, the state value changes. SwiftUI reevaluates the relevant view description and updates the displayed count. Developers do not need to locate the text label and manually replace its contents.
5. Understanding @State
@State is a SwiftUI property wrapper used to store state that belongs to a view. It is especially suitable for simple values such as strings, numbers, Boolean flags, and small collections that are owned by that view.
5.1 Basic @State syntax
import SwiftUI
struct GreetingView: View {
@State private var username = ""
var body: some View {
VStack {
TextField("Enter your name", text: $username)
.textFieldStyle(.roundedBorder)
Text("Hello, \(username.isEmpty ? "Guest" : username)!")
}
.padding()
}
}
In this example, username stores the text entered by the user. The expression $username provides a binding to the state value, allowing the text field to read and update it.
5.2 Common uses of @State
- Showing or hiding a sheet or alert.
- Tracking a selected option.
- Storing text entered into a form.
- Controlling a loading indicator.
- Managing a local counter.
- Storing small pieces of view-specific information.
5.3 Managing a Boolean state
struct PasswordView: View {
@State private var isPasswordVisible = false
var body: some View {
VStack {
Toggle("Show password", isOn: $isPasswordVisible)
Text(isPasswordVisible
? "Password is visible"
: "Password is hidden")
}
.padding()
}
}
This example demonstrates a Boolean state controlling the displayed text. A real password field should use an appropriate secure input control when the password is hidden.
5.4 Best practices for @State
- Use it for state owned by the view.
- Prefer
private for implementation details that should not be modified directly from outside the view.
- Use bindings when child views need to edit a parent's state.
- Avoid treating @State as a general-purpose database or persistence mechanism.
- Remember that state storage is managed by SwiftUI across view updates; a view value being recreated does not necessarily mean its state is reset.
6. Understanding Property Bindings with the $ Prefix
A binding provides read-and-write access to a value that is owned elsewhere. The $ syntax creates a binding from supported property wrappers, such as @State.
import SwiftUI
struct NameEditor: View {
@State private var name = ""
var body: some View {
VStack {
Text("Current name: \(name)")
NameInput(name: $name)
}
.padding()
}
}
struct NameInput: View {
@Binding var name: String
var body: some View {
TextField("Enter name", text: $name)
.textFieldStyle(.roundedBorder)
}
}
The parent owns the name state. The child receives a binding and can edit the same value without creating a separate copy of the state.
6.1 @State vs. @Binding
| Feature |
@State |
@Binding |
| Ownership |
Owns view-local state. |
References state owned elsewhere. |
| Storage |
SwiftUI manages the associated state storage. |
Provides access to an existing source of truth. |
| Typical use |
A parent view stores a value. |
A child view edits the parent's value. |
| Example |
@State private var name = "" |
@Binding var name: String |
7. Understanding ObservableObject and @Published
Before the modern Observation framework was introduced, SwiftUI commonly used Combine's ObservableObject protocol and the @Published property wrapper to observe changes in reference-type models.
A class conforms to ObservableObject to announce changes to its observable state. Properties marked with @Published publish change notifications when their values are modified. SwiftUI views can subscribe to these changes using property wrappers such as @StateObject and @ObservedObject.
7.1 Creating an ObservableObject model
import SwiftUI
import Combine
final class CounterViewModel: ObservableObject {
@Published var count = 0
@Published var message = "Ready"
func increment() {
count += 1
message = "Count updated"
}
}
In this example, CounterViewModel stores two properties that can change. Because they are marked with @Published, subscribers can receive notifications when their values change.
7.2 Using the model with @StateObject
struct ObservableCounterView: View {
@StateObject private var viewModel = CounterViewModel()
var body: some View {
VStack(spacing: 12) {
Text("Count: \(viewModel.count)")
.font(.title)
Text(viewModel.message)
Button("Increment") {
viewModel.increment()
}
}
.padding()
}
}
@StateObject tells SwiftUI to manage the lifetime of the observable object created by this view. When a published property changes, SwiftUI updates the view when its body depends on that change.
7.3 How @Published works
- It is used on properties inside a class that conforms to
ObservableObject.
- It exposes a Combine publisher for the property.
- Changes can notify observers before the new value is fully applied.
- It helps SwiftUI react to changes in model data.
- It is useful for existing projects and applications that use Combine-based observation.
Important: @Published is not required for every property in a model. Use it for properties whose changes should be published to observers. Immutable values, such as a fixed identifier, generally do not need it.
8. Understanding @ObservedObject
@ObservedObject is used when a SwiftUI view receives an existing object conforming to ObservableObject and needs to respond to its published changes. The receiving view does not own the object's lifecycle.
struct CounterDetailsView: View {
@ObservedObject var viewModel: CounterViewModel
var body: some View {
VStack {
Text("Current count: \(viewModel.count)")
Button("Add One") {
viewModel.increment()
}
}
}
}
A parent view can create the object using @StateObject and pass it into this child view.
struct CounterContainerView: View {
@StateObject private var viewModel = CounterViewModel()
var body: some View {
CounterDetailsView(viewModel: viewModel)
}
}
This arrangement separates object ownership from object usage. The parent manages the model instance, while the child observes it and interacts with its data.
9. Understanding the Modern @Observable Macro
Starting with iOS 17 and corresponding releases of Apple's other platforms, SwiftUI supports the Observation framework. The @Observable macro allows a model class to participate in property-access-based observation without requiring the traditional ObservableObject and @Published pattern.
With this approach, properties can be declared normally. SwiftUI tracks the observable properties read by a view and updates dependent views when those properties change.
9.1 Creating a modern observable model
import Observation
import SwiftUI
@Observable
final class ProfileModel {
var username = "Guest"
var points = 0
var isPremium = false
func addPoints(_ value: Int) {
points += value
}
}
The @Observable macro provides observation support for the model's stored properties. Unlike the Combine-based approach, the example does not need to mark each mutable property with @Published.
9.2 Storing the observable model in @State
struct ProfileView: View {
@State private var profile = ProfileModel()
var body: some View {
VStack(spacing: 12) {
Text("Username: \(profile.username)")
Text("Points: \(profile.points)")
Button("Earn 10 Points") {
profile.addPoints(10)
}
}
.padding()
}
}
Here, @State manages the lifetime of the observable model reference, while the Observation framework tracks the model properties used by the view. This is a modern approach for suitable deployment targets.
10. @Observable vs. ObservableObject and @Published
Both approaches help keep SwiftUI interfaces synchronized with changing data, but they use different observation mechanisms.
| Feature |
ObservableObject with @Published |
@Observable |
| Framework |
Combine-based observation |
Observation framework |
| Model declaration |
Class conforms to ObservableObject. |
Class uses the @Observable macro. |
| Property declarations |
Changing properties generally use @Published when they need to publish changes. |
Stored properties are tracked automatically when accessed through supported observation. |
| Typical ownership wrapper |
@StateObject |
@State |
| Typical child-view pattern |
@ObservedObject |
Pass the model reference directly, or use @Bindable when bindings are needed. |
| Shared environment data |
Often uses @EnvironmentObject. |
Can use the typed @Environment pattern. |
| Change tracking |
Object-level change notifications can invalidate observing views. |
Tracks observable properties accessed by a view. |
| Best fit |
Existing Combine-based projects and supported older deployment targets. |
Modern SwiftUI projects targeting supported OS versions. |
These systems can coexist in an application. Choose an approach based on deployment requirements, existing architecture, and the APIs used by the project. Do not apply @ObservedObject to a model that only conforms to the modern Observable protocol.
11. Understanding @Bindable
@Bindable is useful when a view needs to create bindings to mutable properties of a modern @Observable model. It does not own the model; instead, it enables binding syntax such as $profile.username.
import SwiftUI
import Observation
@Observable
final class UserSettings {
var displayName = ""
var notificationsEnabled = true
}
struct SettingsEditorView: View {
@Bindable var settings: UserSettings
var body: some View {
Form {
TextField("Display name", text: $settings.displayName)
Toggle(
"Enable notifications",
isOn: $settings.notificationsEnabled
)
}
}
}
The text field and toggle can update the model directly through their bindings. Because the model is observable, other views that read the affected properties can update accordingly.
12. Managing Shared State Between Views
Applications often display the same data on several screens. For example, a shopping application might show the cart count in its navigation bar and the complete list of items on the cart screen. Keeping separate copies of the same data can cause inconsistencies.
A better approach is to maintain a single source of truth and share the same model instance with the views that need it.
12.1 Passing a modern observable model
import SwiftUI
import Observation
@Observable
final class CartModel {
var itemCount = 0
func addItem() {
itemCount += 1
}
}
struct CartParentView: View {
@State private var cart = CartModel()
var body: some View {
VStack(spacing: 12) {
Text("Cart items: \(cart.itemCount)")
AddToCartView(cart: cart)
}
.padding()
}
}
struct AddToCartView: View {
var cart: CartModel
var body: some View {
Button("Add Item") {
cart.addItem()
}
}
}
Both views use the same model instance. The parent owns the instance, and the child receives a reference to it. Since the model is observable, a view that reads itemCount can react to changes.
12.2 Sharing an observable model through the environment
For deeply nested view hierarchies, passing the same model through every intermediate view can become inconvenient. SwiftUI's typed environment APIs can provide an observable model to descendants.
import SwiftUI
import Observation
@Observable
final class AppSettings {
var darkModeEnabled = false
}
struct SettingsRootView: View {
@State private var settings = AppSettings()
var body: some View {
SettingsDetailsView()
.environment(settings)
}
}
struct SettingsDetailsView: View {
@Environment(AppSettings.self) private var settings
var body: some View {
Toggle(
"Dark Mode",
isOn: Bindable(settings).darkModeEnabled
)
.padding()
}
}
This example uses the modern Observation environment pattern. The model must be supplied by an ancestor. When a descendant needs to edit an observable model property, use a binding mechanism such as @Bindable or Bindable(settings), as appropriate for the view.
13. Managing Loading, Success, and Error States
Many iOS screens load data from APIs. Instead of using a single Boolean for every possible condition, it can be useful to represent the current request state explicitly.
13.1 Defining a screen state
import Foundation
enum LoadingState {
case idle
case loading
case loaded([String])
case failed(String)
}
This enum describes four possible states. A screen can be idle, waiting for a response, displaying loaded results, or showing an error. For larger applications, consider a typed error model or a generic state type to make the design reusable.
13.2 Observable view model example
import SwiftUI
import Observation
@Observable
final class ItemsViewModel {
var state: LoadingState = .idle
@MainActor
func loadItems() async {
state = .loading
do {
try await Task.sleep(for: .seconds(1))
state = .loaded(["Apple", "Banana", "Orange"])
} catch {
if Task.isCancelled {
return
}
state = .failed("Unable to load items.")
}
}
}
This example simulates loading and demonstrates how state can drive the interface. A production implementation would replace the delay with a real asynchronous service request and handle cancellation according to the application's needs.
13.3 Displaying the states in SwiftUI
struct ItemsView: View {
@State private var viewModel = ItemsViewModel()
var body: some View {
Group {
switch viewModel.state {
case .idle, .loading:
ProgressView("Loading...")
case .loaded(let items):
List(items, id: \.self) { item in
Text(item)
}
case .failed(let message):
VStack(spacing: 8) {
Text(message)
Button("Try Again") {
Task {
await viewModel.loadItems()
}
}
}
}
}
.task {
await viewModel.loadItems()
}
}
}
This view shows a progress indicator while loading, a list after success, and a retry button after failure. In a real application, make sure repeated calls do not create unwanted duplicate requests and that errors are represented appropriately.
14. Handling Forms with @State and Observable Models
Forms combine several types of state: user input, validation results, submission progress, and server responses. Keeping the input values and submission logic organized makes forms easier to maintain.
import SwiftUI
import Observation
@Observable
final class RegistrationModel {
var email = ""
var password = ""
var isSubmitting = false
var statusMessage = ""
var isValid: Bool {
email.contains("@") && password.count >= 8
}
@MainActor
func submit() async {
guard isValid, !isSubmitting else {
return
}
isSubmitting = true
defer { isSubmitting = false }
// Replace this demonstration with a real registration service.
statusMessage = "Registration request prepared."
}
}
struct RegistrationView: View {
@State private var model = RegistrationModel()
var body: some View {
@Bindable var form = model
Form {
TextField("Email", text: $form.email)
.textInputAutocapitalization(.never)
.keyboardType(.emailAddress)
SecureField("Password", text: $form.password)
Button("Register") {
Task {
await model.submit()
}
}
.disabled(!model.isValid || model.isSubmitting)
if model.isSubmitting {
ProgressView("Submitting...")
}
Text(model.statusMessage)
}
}
}
The example uses a computed property to validate the input and disables submission when the form is invalid or already submitting. Client-side validation improves usability but does not replace server-side validation.
15. Choosing the Correct State Management Tool
Before adding a property wrapper, identify who owns the value, who needs to read or modify it, and whether the data needs to be observed or persisted.
| Requirement |
Recommended approach |
| A view needs to remember whether a sheet is open. |
@State |
| A child view needs to edit a value owned by its parent. |
@Binding |
| A view owns a modern observable model. |
@State with an @Observable model |
| A modern observable model needs editable property bindings. |
@Bindable |
| An existing Combine model needs to be owned by a view. |
@StateObject |
| A child view receives a Combine-based observable object. |
@ObservedObject |
| A Combine-based object is shared through a view hierarchy. |
@EnvironmentObject |
| A modern observable model must be shared through a hierarchy. |
@Environment with the observable model |
| A value must remain after the application closes. |
Use a persistence mechanism such as SwiftData, Core Data, or a suitable file store. |
16. Common Mistakes and Troubleshooting
| Mistake |
Why it causes problems |
Recommended solution |
| Using @State for every kind of data |
Large application models may become difficult to organize. |
Separate view-local state from shared model data. |
| Creating a new observable object repeatedly |
State can reset or model instances can become inconsistent. |
Use the correct ownership mechanism and a stable source of truth. |
| Using @ObservedObject to own an object |
@ObservedObject is designed to observe an object supplied to a view. |
Use @StateObject when a legacy Combine-based view owns the instance. |
| Using @Published with @Observable unnecessarily |
It mixes two observation approaches without a need. |
Use ordinary stored properties in a modern @Observable model unless there is a specific reason otherwise. |
| Using @ObservedObject with an @Observable-only model |
The model does not satisfy the required ObservableObject contract. |
Pass the model directly or use @Bindable when bindings are needed. |
| Duplicating the same state across screens |
Views may display conflicting values. |
Share a single source of truth. |
| Publishing immutable values |
Unnecessary notifications add noise. |
Publish only changing values that need observation. |
| Ignoring loading and error states |
Users may not know whether an operation is running or has failed. |
Represent idle, loading, success, and failure explicitly. |
| Expecting UI state to persist permanently |
View state is not automatically durable storage. |
Use a persistence framework for data that must survive app restarts. |
17. Best Practices for UI State Management
- Keep a clear single source of truth for each piece of state.
- Use
@State for values and model references owned by a view.
- Use
@Binding when a child view needs to modify parent-owned state.
- Use
@Observable for modern observable models on supported platforms.
- Use
ObservableObject and @Published where the Combine-based approach is appropriate.
- Separate view code from business logic using models and view models.
- Represent loading, success, empty, and error states clearly.
- Keep UI-related mutations appropriately isolated to the main actor when asynchronous work is involved.
- Avoid storing secrets or sensitive persistent information in ordinary UI state.
- Test state changes, validation, cancellation, and error handling.
18. Practical Project: Profile Settings Screen
Build a profile settings screen that lets users edit their display name, enable notifications, and see a live summary of their current preferences.
Project requirements
- Create an observable settings model.
- Store the display name and notification preference.
- Use text fields and toggles to update the model.
- Show a summary that updates when values change.
- Separate reusable editor components from the main view.
- Add a save button and a confirmation message.
- Optionally persist settings between application launches.
Suggested implementation steps
- Define a model using either
@Observable or ObservableObject.
- Choose the appropriate ownership mechanism for the model.
- Create the main SwiftUI view.
- Add text fields and toggles using bindings.
- Display a summary using the same source of truth.
- Test whether all dependent views update after changes.
- Add persistence if the values must survive app restarts.
19. Practice Exercises
- Create a counter using
@State and add increment, decrement, and reset buttons.
- Build a login form with email and password validation.
- Create a Combine-based view model using
ObservableObject and @Published.
- Pass an observable object from a parent view to a child using
@ObservedObject.
- Rewrite a simple Combine-based model using
@Observable.
- Use
@Bindable to edit properties of a modern observable model.
- Create a shared settings model for multiple SwiftUI views.
- Display loading, success, and error states for a simulated API request.
- Build a product cart where the cart count updates in multiple views.
- Persist a settings value and restore it when the app launches again.
20. Interview Questions and Answers
Q1. What is UI state in SwiftUI?
Answer: UI state is the data that determines how a view appears and behaves. SwiftUI updates dependent parts of the interface when observed state changes.
Q2. What is @State?
Answer: @State allows SwiftUI to manage storage for a value or an owned model reference used as the source of truth within a view hierarchy.
Q3. What is @Published?
Answer: @Published is a Combine property wrapper that publishes changes for a property in an appropriate observable object.
Q4. What is ObservableObject?
Answer: ObservableObject is a Combine protocol used by reference types that publish object changes to subscribers.
Q5. What is the @Observable macro?
Answer: It enables property-access-based observation for a model type using Apple's Observation framework.
Q6. What is the difference between @StateObject and @ObservedObject?
Answer: @StateObject is used when a SwiftUI view owns and manages the lifetime of a Combine-based observable object. @ObservedObject is used when a view receives an existing observable object to observe.
Q7. What is the difference between @Observable and ObservableObject?
Answer: @Observable uses the Observation framework and tracks properties accessed by dependent views. ObservableObject uses Combine-based object change notifications and commonly works with @Published properties.
Q8. What is @Binding?
Answer: @Binding provides read-and-write access to state owned elsewhere, allowing child views to edit a value without owning its storage.
Q9. When should @Bindable be used?
Answer: Use @Bindable when a view needs bindings to mutable properties of an object using the modern @Observable system.
Q10. Does @State automatically save data permanently?
Answer: No. @State is for SwiftUI-managed state, not durable storage. Use a suitable persistence mechanism for data that must survive application restarts.
Q11. Why is a single source of truth important?
Answer: It prevents different parts of an application from maintaining conflicting copies of the same information.
Q12. Can @Observable and ObservableObject coexist in one application?
Answer: Yes. An application can use both systems, especially when migrating an existing project gradually or supporting different deployment requirements.
21. Quick Revision Checklist
- Understand state-driven and declarative UI design.
- Use @State for view-owned state.
- Use @Binding for parent-owned values edited by child views.
- Understand ObservableObject and @Published.
- Know when to use @StateObject and @ObservedObject.
- Understand modern @Observable models.
- Use @Bindable to create editable bindings to observable model properties.
- Share state through direct references or environment mechanisms when appropriate.
- Represent loading, success, and error states clearly.
- Separate transient UI state from persistent data.
- Test that state changes update the expected views.
22. Conclusion
Managing UI state is a fundamental part of building reliable SwiftUI applications. @State is useful for view-owned values, ObservableObject and @Published support Combine-based observation, and @Observable provides a modern way to track changes to model properties.
By choosing the correct ownership and observation tools, using bindings to share editable state, and maintaining a single source of truth, developers can create interfaces that remain consistent as data changes. Good state management also makes applications easier to test, extend, and maintain.
23. Additional Learning Resources